Fulfill input_required results on the legacy wire per SEP-2322 - #501
Open
koic wants to merge 1 commit into
Open
Conversation
## Motivation and Context SEP-2322 handlers return `InputRequiredResult` instead of issuing in-flight server-to-client requests, but that result type exists only on the 2026-07-28 wire: pre-2026 clients treat an unknown `resultType` as a final result, so the server rejected `input_required` on legacy requests with an internal error. That forced authors to keep two handler styles, one per era. This adds the dual-era authoring shim both reference implementations converged on: the TypeScript SDK's default-on legacy fulfilment shim (typescript-sdk#2381) and the Python SDK's version-negotiating resolver (python-sdk#2986). When a `tools/call`, `prompts/get`, or `resources/read` handler returns an `InputRequiredResult` on the legacy wire, the server fulfills it in place of the client's driver: - Every `inputRequests` entry is sent verbatim as the equivalent real server-to-client request through a new `ServerSession#fulfill_input_request` (per the spec, clients treat each embedded entry exactly like the standalone request), stamped with the originating request id so the association requirement of SEP-2260 holds and Streamable HTTP delivery rides the originating POST stream. - The answers are collected under the same keys and the handler re-runs with `inputResponses` and the raw `requestState` merged into the original params - the same deterministic replay contract the modern client driver follows, read through the existing `server_context.input_responses`/`request_state` accessors. Only the current round's responses are sent, matching the client driver; handlers carry earlier answers through `requestState`. - `requestState` round-trips in-process as the value the handler wrote. `RequestStateSecurity` sealing is wire hardening against a tampering client and does not apply to the in-process replay. - The embedded-capability gate still runs each round against the session's declared client capabilities; a violation fails before any client contact. The `-32021` code is 2026-only, so the legacy wire gets an internal error naming the missing capabilities. - Rounds are capped at 8 (`LEGACY_INPUT_REQUIRED_MAX_ROUNDS`), matching the TypeScript shim's `maxRounds` default; exhaustion fails with an explanatory internal error, which also bounds the `requestState`-only load-shedding form that cannot make progress in-process. The shim is on by default, matching the TypeScript SDK; `Server.new(input_required_legacy_shim: false)` restores the strict rejection. Requests without a session (no notification path back to a client) and all modern requests are untouched: the modern wire keeps returning the `input_required` result for the client driver. Resolves modelcontextprotocol#382. ## How Has This Been Tested? `bundle exec rake` passes: 1601 test runs / 4094 assertions with zero failures and RuboCop clean. The frozen 2025-11-25 conformance server leg passes 78/78 (the legacy wire is where the shim lives), and the modern `input-required-result-*` scenarios stay green, confirming the modern path is untouched. New `test/mcp/server_input_required_legacy_shim_test.rb` covering: completion through a real elicitation with the SEP-2260 association asserted on the transport, replay exposing the collected responses and the raw `requestState`, sealing bypass under `RequestStateSecurity`, `prompts/get` and `resources/read` shimming, multi-round collection, the round cap, the capability gate failing before client contact, the opt-out, session-less rejection, and modern requests staying untouched. The pre-existing legacy rejection test (session-less) and all modern MRTR serialization tests pass unchanged. ## Breaking Changes Only for handlers that returned `InputRequiredResult` to legacy clients THROUGH A SESSION and relied on the internal-error rejection: those now drive a real fulfilment round trip by default. No such handler could have worked end to end before (the request always failed), so this is the intended upgrade; `input_required_legacy_shim: false` restores the previous behavior. Everything else is unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
SEP-2322 handlers return
InputRequiredResultinstead of issuing in-flight server-to-client requests, but that result type exists only on the 2026-07-28 wire: pre-2026 clients treat an unknownresultTypeas a final result, so the server rejectedinput_requiredon legacy requests with an internal error. That forced authors to keep two handler styles, one per era.This adds the dual-era authoring shim both reference implementations converged on: the TypeScript SDK's default-on legacy fulfilment shim (typescript-sdk#2381) and the Python SDK's version-negotiating resolver (python-sdk#2986). When a
tools/call,prompts/get, orresources/readhandler returns anInputRequiredResulton the legacy wire, the server fulfills it in place of the client's driver:inputRequestsentry is sent verbatim as the equivalent real server-to-client request through a newServerSession#fulfill_input_request(per the spec, clients treat each embedded entry exactly like the standalone request), stamped with the originating request id so the association requirement of SEP-2260 holds and Streamable HTTP delivery rides the originating POST stream.inputResponsesand the rawrequestStatemerged into the original params - the same deterministic replay contract the modern client driver follows, read through the existingserver_context.input_responses/request_stateaccessors. Only the current round's responses are sent, matching the client driver; handlers carry earlier answers throughrequestState.requestStateround-trips in-process as the value the handler wrote.RequestStateSecuritysealing is wire hardening against a tampering client and does not apply to the in-process replay.-32021code is 2026-only, so the legacy wire gets an internal error naming the missing capabilities.LEGACY_INPUT_REQUIRED_MAX_ROUNDS), matching the TypeScript shim'smaxRoundsdefault; exhaustion fails with an explanatory internal error, which also bounds therequestState-only load-shedding form that cannot make progress in-process.The shim is on by default, matching the TypeScript SDK;
Server.new(input_required_legacy_shim: false)restores the strict rejection. Requests without a session (no notification path back to a client) and all modern requests are untouched: the modern wire keeps returning theinput_requiredresult for the client driver.Resolves #382.
How Has This Been Tested?
bundle exec rakepasses: 1601 test runs / 4094 assertions with zero failures and RuboCop clean. The frozen 2025-11-25 conformance server leg passes 78/78 (the legacy wire is where the shim lives), and the moderninput-required-result-*scenarios stay green, confirming the modern path is untouched.New
test/mcp/server_input_required_legacy_shim_test.rbcovering: completion through a real elicitation with the SEP-2260 association asserted on the transport, replay exposing the collected responses and the rawrequestState, sealing bypass underRequestStateSecurity,prompts/getandresources/readshimming, multi-round collection, the round cap, the capability gate failing before client contact, the opt-out, session-less rejection, and modern requests staying untouched.The pre-existing legacy rejection test (session-less) and all modern MRTR serialization tests pass unchanged.
Breaking Changes
Only for handlers that returned
InputRequiredResultto legacy clients THROUGH A SESSION and relied on the internal-error rejection: those now drive a real fulfilment round trip by default. No such handler could have worked end to end before (the request always failed), so this is the intended upgrade;input_required_legacy_shim: falserestores the previous behavior. Everything else is unchanged.Types of changes
Checklist
Additional context